/* * Sun Public License Notice * * The contents of this file are subject to the Sun Public License * Version 1.0 (the "License"). You may not use this file except in * compliance with the License. A copy of the License is available at * http://www.sun.com/ * * The Original Code is Forte for Java, Community Edition. The Initial * Developer of the Original Code is Sun Microsystems, Inc. Portions * Copyright 1997-2000 Sun Microsystems, Inc. All Rights Reserved. */ package org.netbeans.editor; import java.io.Writer; import java.io.IOException; import javax.swing.text.BadLocationException; /** * Document cache * The cache is used to perform insert/remove/read/find operations * over the document. The document data are partly stored in cache fragments * and partly in cache support. * Cache can contain several non-overlapping fragments. * At all times there is one fragment called default fragment. * It's used for all operations of callers that don't pass valid fragment * information to the insert/remove and other methods. * * @author Miloslav Metelka * @version 1.00 */ class DocCache { /** Old contents of the fragment will be reused if backward * overlapping is at least this len */ private static final int MIN_FRAGMENT_BACK_OVERLAP_LEN = 256; /** CacheSupport that this cache uses. Cache support holds the whole * document. There are two obvious storages - char array held in memory * and file based storage. When there are changes made to the cache, * they are held in fragments of the cache until they become so big * that they must be flushed to the support. */ private DocCacheSupport support; /** Array of all fragments that this cache uses */ private Fragment[] frags; /** Default fragment. It is used when null is passed as segment * to insert/remove and other operations. It's also used when docLen * is 0 to be the only one fragment available for insertion. */ private Fragment defFrag; /** Direct mode allowing to forward all operations directly * to cache support. This is useful when support is memory based. * No cache fragments are created for direct mode to save memory. */ private boolean directMode; /** Document length difference of all the fragments against * the length of document that is given by support document len. * Formula: support document len + docLenDelta = total document len */ private int docLenDelta = 0; /* Statistics */ public int statRead = 0; public int statInsert = 0; public int statRemove = 0; public int statReadFragCnt = 0; public int statWriteFragCnt = 0; public int statOverlapCnt = 0; public int statBackOverlapCnt = 0; public int statFragSwitchCnt = 0; public int statFragSetEmpty = 0; /** Create the new cache with the specified size of default fragment. * @param support CacheSupport to use for the document * @param len Length of the default fragment * @param directMode whether all operations should be routed to support */ public DocCache(DocCacheSupport support, int len, boolean directMode) { this.support = support; if (directMode && !support.supportsDirectMode()) { directMode = false; } this.directMode = directMode; if (!directMode) { defFrag = addFragment(len); } } /** Set initial content of the cache. This function may be called * only once, after construction, before the data in cache are read * or manipulated. Otherwise the cache content will be damaged. * The reason for using this function is that content that's * read from reader and that should be put into support can be also * put directly into cache which saves whole support read. * @param initCache initial cache data * @param offset first valid offset in initial cache * @param cacheLen length of data initial cache */ void initCacheContent(char initCache[], int offset, int cacheLen) { if (directMode) { return; } if (initCache != null) { defFrag.fragLen = Math.min(defFrag.buffer.length, cacheLen - offset); defFrag.origLen = defFrag.fragLen; System.arraycopy(initCache, offset, defFrag.buffer, 0, defFrag.fragLen); defFrag.startPos = 0; } } public synchronized Fragment addFragment(int fragLen) { if (directMode) { return null; } Fragment f = new Fragment(fragLen); if (frags != null) { Fragment[] tmpFrags = new Fragment[frags.length + 1]; System.arraycopy(frags, 0, tmpFrags, 0, frags.length); tmpFrags[frags.length] = f; frags = tmpFrags; } else { frags = new Fragment[1]; frags[0] = f; } return f; } /** Flush the whole cache to support */ public synchronized void flush() { if (directMode) { return; } for (int i = 0; i < frags.length; i++) { if (frags[i].valid) { frags[i].write(); } } } /** Read fragment and ensure that specified position will be * inside (or at the end) of the fragment's cache. * @param frag fragment to read * @param pos position that must be inside the fragment */ private void readFrag(Fragment frag, int pos) { int len; // len of chars to be read Fragment f; if (frag.modified) { frag.write(); // flush this fragment if modified } int mantLow = 0, mantHigh = getDocLenImpl(); // mantinels int csDelta = 0; int i; for (i = 0; i < frags.length; i++) { f = frags[i]; if (f.valid && f != frag) { if (f.startPos > pos) { mantHigh = f.startPos; break; } else { csDelta += f.origLen - f.fragLen; mantLow = f.startPos + f.fragLen; } } } // count position from which should be read pos = Math.max(pos - frag.buffer.length / 2, mantLow); len = Math.min(mantHigh - pos, frag.buffer.length); frag.read(pos, len, csDelta); // repair the fragment's position in frags if (i == frags.length || (frags[i] != frag && (i == 0 || frags[i - 1] != frag)) ) { statFragSwitchCnt++; int fragInd; for (fragInd = 0; fragInd < frags.length; fragInd++) { if (frags[fragInd] == frag) { break; } } if (fragInd < i) { // frag before i for (int j = fragInd + 1; j < i; j++) { frags[j - 1] = frags[j]; } frags[i - 1] = frag; } else { // frag after i for (int j = fragInd; j > i; j--) { frags[j] = frags[j - 1]; } frags[i] = frag; } } frag.valid = true; } /** Get the correct fragment for a given position. * Search all the fragments if there's one that contains * given position. If not get or create optional fragment * and read into it. * @param pos position that the fragment should contain * @param frag current fragment * @param wantInsert want to perform insert on got fragment * @return fragment that will contain position */ private Fragment getFrag(int pos, Fragment frag, boolean wantInsert) { Fragment f; for (int i = 0; i < frags.length; i++) { f = frags[i]; if (f.valid) { if (wantInsert) { if (f.isIn(pos)) { if (pos == f.startPos + f.fragLen) { // right at the end of frag i++; while (i < frags.length) { if (frags[i].valid) { if (frags[i].startPos == pos) { // successive frag f = frags[i]; break; } } i++; } } // test if fragment full for insertions if (f.modified && f.lastMod == f.buffer.length) { readFrag(f, pos); } return f; } } else { if (f.isInside(pos)) { return f; } } } } // check for zero document len if (getDocLenImpl() == 0) { // should be just for inserts for (int i = 0; i < frags.length; i++) { f = frags[i]; if (f.valid) { return f; // in fact there should be any valid } } // no valid fragment, make defFrag active defFrag.setEmptyValid(); return defFrag; } // get the fragment and read into it if (frag != null) { f = frag.actFrag = frag; } else { f = defFrag; } readFrag(f, pos); return f; } /** Update starting positions of all fragments * that have higher start positions than the given one * @param frag fragment that will not be updated * @param pos position over which the fragments will be updated * @param delta how much increase/decrease starting positions */ private void updateStartPos(Fragment frag, int pos, int delta) { for (int i = frags.length - 1; i >= 0; i--) { Fragment f = frags[i]; if (f.valid) { if (f.startPos >= pos) { if (f != frag) { f.startPos += delta; } } else { break; // reached lowest position } } } } /** Insert the array of chars into some position. It can be done * in one or more cycles. * @param pos from which position * @param text text to insert * @param frag dedicated fragment (can be null) */ public synchronized void insert(int pos, char text[], Fragment frag) throws BadLocationException { if(pos < 0 || pos > getDocLenImpl()) { throwPosException(pos); } int restLen = text.length; // rest of len to insert if (restLen == 0) { return; } if (directMode) { support.insert(pos, text, 0, restLen); return; } int wrLen; // write len in one cycle int bufPos; // relative position from the start of cache int moveLen; // how much data will be moved inside cache to make space Fragment f = (frag == null) ? defFrag : frag.actFrag; boolean bufOK = false; if (f.isInside(pos) && (!f.modified || f.lastMod < f.buffer.length)) { bufOK = true; // pos inside and buffer not full for insertion } while (restLen > 0) { // till all the data will be inserted // is pos in current fragment if (bufOK) { bufOK = false; } else { f = getFrag(pos, frag, true); // get fragment for inserting } bufPos = pos - f.startPos; if (!f.modified) { f.modified = true; f.firstMod = f.lastMod = bufPos; } wrLen = f.buffer.length - Math.max(bufPos, f.lastMod); wrLen = Math.min(wrLen, restLen); // find how many bytes should be moved inside the cache moveLen = Math.min(f.fragLen - bufPos, f.buffer.length - (bufPos + wrLen)); if (moveLen > 0) { // now make space for inserted data System.arraycopy(f.buffer, bufPos, f.buffer, bufPos + wrLen, moveLen); } // move the data from text array to the cache System.arraycopy(text, text.length - restLen, f.buffer, bufPos, wrLen); f.updatePos(bufPos, wrLen); // correct positions in the fragment docLenDelta += wrLen; if (frags.length > 1) { updateStartPos(f, pos, wrLen); // correct positions of other frags } restLen -= wrLen; pos += wrLen; } if (frag != null) { frag.actFrag = f; } statInsert += text.length; // update insert statistics } /** Insert the string at some position. It can be done * in one or more cycles. * @param pos from which position * @param text text to insert * @param frag dedicated fragment (can be null) */ public synchronized void insertString(int pos, String text, Fragment frag) throws BadLocationException { if(pos < 0 || pos > getDocLenImpl()) { throwPosException(pos); } int textLen = text.length(); int restLen = textLen; // rest of len to insert if (restLen == 0) { return; } if (directMode) { support.insertString(pos, text, 0, restLen); return; } int wrLen; // write len in one cycle int bufPos; // relative position from the start of cache int moveLen; // how much data will be moved inside cache to make space Fragment f = (frag == null) ? defFrag : frag.actFrag; boolean bufOK = false; if (f.isInside(pos) && (!f.modified || f.lastMod < f.buffer.length)) { bufOK = true; // pos inside and buffer not full for insertion } while (restLen > 0) { // till all the data will be inserted // is pos in current fragment if (bufOK) { bufOK = false; } else { f = getFrag(pos, frag, true); // get fragment for inserting } bufPos = pos - f.startPos; if (!f.modified) { f.modified = true; f.firstMod = f.lastMod = bufPos; } wrLen = f.buffer.length - Math.max(bufPos, f.lastMod); wrLen = Math.min(wrLen, restLen); // find how many bytes should be moved inside the cache moveLen = Math.min(f.fragLen - bufPos, f.buffer.length - (bufPos + wrLen)); if (moveLen > 0) { // now make space for inserted data System.arraycopy(f.buffer, bufPos, f.buffer, bufPos + wrLen, moveLen); } // move the data from text to the cache text.getChars(textLen - restLen, textLen - restLen + wrLen, f.buffer, bufPos); f.updatePos(bufPos, wrLen); // correct positions in the fragment docLenDelta += wrLen; if (frags.length > 1) { updateStartPos(f, pos, wrLen); // correct positions of other frags } restLen -= wrLen; pos += wrLen; } if (frag != null) { frag.actFrag = f; } statInsert += textLen; // update insert statistics } /** Remove the specified count of chars from the specified position. * @param pos position from which remove the text * @param len length of the data to be removed * @param frag dedicated fragment (can be null) * @returns the removed text */ public synchronized void remove(int pos, int len, Fragment frag) throws BadLocationException { int removeLen; // remove length inside the cache int restLen = len; // remaining len to remove int bufPos; if (len == 0) { return; } if (pos < 0) { throwPosException(pos); } if (pos + len > getDocLenImpl()) { throwPosException(pos + len); } if (directMode) { support.remove(pos, len); return; } // get the fragment Fragment f = (frag == null) ? defFrag : frag.actFrag; // first check if the removed block is fully in the cache if (pos < 0 || pos + len > f.startPos + f.fragLen) { // test position correctness } while (restLen > 0) { // get the correct fragment if (!f.isInside(pos)) { f = getFrag(pos, f, false); } // get the positions bufPos = pos - f.startPos; removeLen = Math.min(restLen, f.fragLen - bufPos); System.arraycopy(f.buffer, bufPos + removeLen, f.buffer, bufPos, f.fragLen - (bufPos + removeLen)); if(!f.modified) { f.modified = true; f.firstMod = f.lastMod = bufPos; } f.updatePos(bufPos, -removeLen); docLenDelta -= removeLen; if(frags.length > 1) { updateStartPos(f, pos, -removeLen); // correct positions of other frags } restLen -= removeLen; } if(frag != null) { frag.actFrag = f; } statRemove += len; } /** Read the data from the cache. Output array must be provided. * @param pos position from which read starts * @param ret char array where the data should be put. There must * be enough space in array to hold len requested characters * @param offset offset in return array where data will be written * @param len len to read * @param frag dedicated fragment (can be null) */ public synchronized void read(int pos, char ret[], int offset, int len, Fragment frag) throws BadLocationException { if (pos < 0 || len < 0) { throwPosException(pos); } if (pos + len > getDocLenImpl()) { throwPosException(pos + len); } if (directMode) { support.read(pos, ret, offset, len); return; } int getLen, bufPos; int restLen = len; // the rest to retrieve // get the fragment Fragment f = (frag == null) ? defFrag : frag.actFrag; while(restLen > 0) { // check if pos is inside fragment if(!f.isInside(pos)) { f = getFrag(pos, frag, false); } bufPos = pos - f.startPos; // position in cache getLen = Math.min(f.fragLen - bufPos, restLen); // copy into return array System.arraycopy(f.buffer, bufPos, ret, len - restLen + offset, getLen); pos += getLen; restLen -= getLen; } if (frag != null) { frag.actFrag = f; } statRead += len; } /** Read the data from the cache and return reults as char array. * @param pos position from which read starts * @param len len to read * @param frag dedicated fragment (can be null) * @return char array with data from document */ public final char[] read(int pos, int len, Fragment frag) throws BadLocationException { if (len < 0) { throwPosException(pos); } char ret[] = new char[len]; read(pos, ret, 0, len, frag); return ret; } /** Find something in the cache using specified <CODE>Finder</CODE>. It covers * both forward and backward searches. To do backward search, specify * endPos < startPos. The following position intervals are searched: * forward search: <startPos, endPos) - from startPos to endPos - 1 * backward search: <endPos, startPos) - from startPos - 1 to endPos * Both startPos and endPos can be -1 to indicate end of document position * @param finder finder that will be used for searching * @param startPos position from which search starts. For backward search * this value is greater or equal than <CODE>endPos</CODE>. * @param endPos limit position of search area * For backward search this value is lower than <CODE>startPos</CODE>. * @param frag dedicated fragment (can be null) * @return position where the found string begins or -1 if the text was not found */ public synchronized int find(Finder finder, int startPos, int endPos, Fragment frag) throws BadLocationException { int docLen = getDocLenImpl(); if (startPos == -1) { startPos = docLen; } if (endPos == -1) { endPos = docLen; } // check bounds if (startPos < 0 || startPos > docLen) { throwPosException(startPos); } if (endPos < 0 || endPos > docLen) { throwPosException(endPos); } finder.reset(); // initialize finder if (startPos == endPos) { // immediate return for void search return -1; } boolean forward = (startPos < endPos); int pos = forward ? startPos : (startPos - 1); if (directMode) { while (true) { pos = finder.find(0, support.getDirectModeBuffer(), forward ? startPos : endPos, forward ? endPos : startPos, pos, endPos); if (finder.isFound()) { if (forward) { if (pos < startPos || pos > endPos) { return -1; // invalid position returned } } else { // searching backward if (pos < endPos || pos > startPos) { return -1; // invalid position returned } } return pos; } else { // not yet found // Check position correctness. It eliminates // also the equalities because the empty buffer // would be pzssed in these cases to the finder if (forward) { // searching forward if (pos < startPos || pos >= endPos) { return -1; } } else { // searching backward if (pos < endPos || pos >= startPos) { return -1; // not found } } } } } // get the fragment Fragment f = (frag == null) ? defFrag : frag.actFrag; while (true) { // check if pos is inside fragment if(!f.isInside(pos)) { f = getFrag(pos, frag, false); } int offset1 = Math.max(forward ? startPos : endPos, f.startPos) - f.startPos; int offset2 = Math.min(forward ? endPos : startPos, f.startPos + f.fragLen) - f.startPos; pos = finder.find(f.startPos, f.buffer, offset1, offset2, pos, endPos); // check found position correctness if (finder.isFound()) { if (forward) { if (pos < startPos || pos > endPos) { return -1; // invalid position returned } } else { // searching backward if (pos < endPos || pos > startPos) { return -1; // invalid position returned } } break; } else { // not yet found // Check position correctness. It eliminates // also the equalities because the empty buffer // would be pzssed in these cases to the finder if (forward) { // searching forward if (pos < startPos || pos >= endPos) { return -1; } } else { // searching backward if (pos < endPos || pos >= startPos) { return -1; // not found } } } } if (frag != null) { frag.actFrag = f; } return pos; // return found position } /** Get the total length of the document. */ public synchronized final int getDocLength() { return getDocLenImpl(); } private int getDocLenImpl() { return support.getDocLength() + docLenDelta; } /** Fragment of the cache */ private class Fragment { /** buffer space of the fragment */ char buffer[]; /** Position of the first character cached in the fragment */ int startPos = -1; /** Number of chars in the buffer of this fragment. * If this value is 0, it means that the fragment * is invalid. */ int fragLen; /** Original len when the buffer was read */ int origLen; /** First modified char in the buffer */ int firstMod; /** Offset of last modified char int the buffer */ int lastMod; /** Was the buffer modified or not */ boolean modified; /** Is this fragment valid? Fragment becomes invalid * when it contains no data. */ boolean valid; /** Actual fragment which is either this fragment or when the last * search didn't succeeded in this fragment, some other fragment. */ Fragment actFrag; /** Construct new fragment with specified length * @param len len of the constructed fragment */ Fragment(int len) { buffer = new char[len]; actFrag = this; } /** Is the position inside this fragment? */ boolean isInside(int pos) { return (pos >= startPos) && (pos < startPos + fragLen); } /** Is the position inside or at the end of this fragment? */ boolean isIn(int pos) { return (pos >= startPos) && (pos <= startPos + fragLen); } /** Flush the fragment to support */ void write() { if(!modified) { return; } int endModPos = startPos + lastMod; // last modification int writeLen; // count the support delta int csDelta = 0; for (int i = 0; i < frags.length; i++) { Fragment f = frags[i]; if (f.valid) { if(f.startPos >= this.startPos) { break; } csDelta += f.origLen - f.fragLen; } } try { if (fragLen != origLen) { // will either enlarge or shrink document int delta = fragLen - origLen; if(delta > 0) { // delta > 0; need to enlarge document support.insert(endModPos - delta + csDelta, buffer, lastMod - delta, delta); writeLen = lastMod - firstMod - delta; } else { // delta < 0; need to shrink the document support.remove(endModPos + csDelta, -delta); writeLen = lastMod - firstMod; } } else { // delta is zero i.e. length of the buffer is the same writeLen = lastMod - firstMod; } if(writeLen > 0) { try { support.write(startPos + firstMod + csDelta, buffer, firstMod, writeLen); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } } } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } docLenDelta += origLen - fragLen; origLen = fragLen; modified = false; statWriteFragCnt++; } /** Read the fragment to contain specified position * @param pos start position * @param len length to read * @param csDelta delta by which the reading position must be corrected * when reading through support */ void read(int pos, int len, int csDelta) { int overlap; if (pos < startPos) { // read position is lower than start of frag overlap = Math.min(pos + len - startPos, fragLen); if (overlap > MIN_FRAGMENT_BACK_OVERLAP_LEN) { statBackOverlapCnt++; System.arraycopy(buffer, 0, buffer, startPos - pos, overlap); try { support.read(pos + csDelta, buffer, 0, startPos - pos); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } overlap = startPos + overlap; // now overlap means pos of end of block if(overlap < pos + len) { try { support.read(startPos + fragLen + csDelta, buffer, overlap - pos, pos + len - overlap); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } } } else { // no or small overlap try { support.read(pos + csDelta, buffer, 0, len); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } } } else { // pos >= startPos overlap = startPos + fragLen - pos; if(overlap > 0) { // here any overlap is fine !!! statOverlapCnt++; System.arraycopy(buffer, pos - startPos, buffer, 0, overlap); try { support.read(pos + overlap + csDelta, buffer, overlap, len - overlap); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } } else { // no overlap try { support.read(pos + csDelta, buffer, 0, len); } catch(BadLocationException e) { if (Boolean.getBoolean("netbeans.debug.exceptions")) { // NOI18N e.printStackTrace(); } } } } startPos = pos; fragLen = origLen = len; valid = true; statReadFragCnt++; } /** Invalidate segment so it will no longer be used for operations */ void invalidate() { write(); // flush the fragment valid = false; startPos = -1; // important for isIn() and others fragLen = origLen = 0; } /** Set this segment as valid even when it has zero size. It is done * only when total doc len is 0. */ void setEmptyValid() { statFragSetEmpty++; fragLen = origLen = 0; startPos = 0; valid = true; } /** Update various positions in fragment after modification. * @param bufPos position in the buffer where the modification was done * @param delta of the change */ void updatePos(int bufPos, int delta) { if (bufPos < firstMod) { firstMod = bufPos; } if (delta > 0) { // insert was done if (bufPos > lastMod) { lastMod = bufPos + delta; } else { lastMod += delta; } fragLen += delta; if (fragLen > buffer.length) { origLen -= fragLen - buffer.length; fragLen = buffer.length; } } else { // remove was done if (bufPos - delta <= lastMod) { // if whole block below lastMod lastMod += delta; } else { lastMod = bufPos; } fragLen += delta; if (fragLen == 0) { // empty fragment must be invalidated invalidate(); } } } public String toString() { int i; for (i = 0; i < frags.length; i++) { if (frags[i] == this) { break; } } return "Frag[" + i + "] valid=" + valid + ", startPos=" + startPos // NOI18N + ", fragLen=" + fragLen + ", origLen=" + origLen // NOI18N + ", fMod=" + firstMod + ", lMod=" + lastMod // NOI18N + ", mod=" + modified; // NOI18N } } /** Throw position exception */ final void throwPosException(int pos) throws BadLocationException { throw new BadLocationException("DocCache: Invalid offset " + pos // NOI18N + ". Document length is " + getDocLenImpl(), pos); // NOI18N } public String toString() { String ret = "support=" + support; // NOI18N if (directMode) { ret += ", Direct mode, no fragments\n"; // NOI18N } else { ret += ", fragment count=" + frags.length + "\n"; // NOI18N for (int i = 0; i < frags.length; i++) { ret += frags[i] + "\n"; // NOI18N } } ret += " getDocLength()=" + getDocLength() + ", docLenDelta=" + docLenDelta // NOI18N + ", statRead=" + statRead + ", statInsert=" // NOI18N + statInsert + ", statRemove=" + statRemove // NOI18N + ", statReadFragCnt=" + statReadFragCnt // NOI18N + ", statWriteFragCnt=" + statWriteFragCnt // NOI18N + ", statOverlapCnt=" + statOverlapCnt // NOI18N + ", statBackOverlapCnt=" + statBackOverlapCnt // NOI18N + ", statFragSwitchCnt=" + statFragSwitchCnt // NOI18N + ", statFragSetEmpty=" + statFragSetEmpty; // NOI18N return ret; } } /* * Log * 19 Gandalf-post-FCS1.17.1.0 4/3/00 Miloslav Metelka undo update * 18 Gandalf 1.17 1/13/00 Miloslav Metelka * 17 Gandalf 1.16 1/10/00 Miloslav Metelka * 16 Gandalf 1.15 1/6/00 Miloslav Metelka * 15 Gandalf 1.14 11/14/99 Miloslav Metelka * 14 Gandalf 1.13 11/8/99 Miloslav Metelka * 13 Gandalf 1.12 10/23/99 Ian Formanek NO SEMANTIC CHANGE - Sun * Microsystems Copyright in File Comment * 12 Gandalf 1.11 10/10/99 Miloslav Metelka * 11 Gandalf 1.10 9/16/99 Miloslav Metelka * 10 Gandalf 1.9 9/10/99 Miloslav Metelka * 9 Gandalf 1.8 7/20/99 Miloslav Metelka * 8 Gandalf 1.7 6/25/99 Miloslav Metelka from floats back to ints * 7 Gandalf 1.6 6/10/99 Miloslav Metelka * 6 Gandalf 1.5 5/5/99 Miloslav Metelka * 5 Gandalf 1.4 4/23/99 Miloslav Metelka Undo added and internal * improvements * 4 Gandalf 1.3 4/8/99 Miloslav Metelka * 3 Gandalf 1.2 3/30/99 Miloslav Metelka * 2 Gandalf 1.1 3/18/99 Miloslav Metelka * 1 Gandalf 1.0 2/3/99 Miloslav Metelka * $ */